redux, recoil과는 다르게 zustand는 어떻게 root provider 없이 상태관리를 하는지 궁금했다.
아래는 리덕스 공식문서 튜토리얼에서 가져온 예제이다. https://ko.redux.js.org/tutorials/fundamentals/part-5-ui-react/#passing-the-store-with-provider
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import App from './App'
import store from './store'
ReactDOM.render(
// Render a `<Provider>` around the entire `<App>`,
// and pass the Redux store to as a prop
<React.StrictMode>
<Provider store={store}> <-------------------------- provider
<App />
</Provider>
</React.StrictMode>,
document.getElementById('root')
)
아래는 recoil 예제이다. https://recoiljs.org/ko/docs/introduction/getting-started
import React from 'react';
import {
RecoilRoot,
atom,
selector,
useRecoilState,
useRecoilValue,
} from 'recoil';
function App() {
return (
<RecoilRoot> <-------------------------- provider
<CharacterCounter />
</RecoilRoot>
);
}
이렇듯 전역상태 라이브러리는 앱의 최상단에 위치하여 상태를 관리한다. https://www.slash.page/ko/libraries/react/use-overlay/src/useOverlay.i18n toss에서 만든 modal을 선언적으로 관리하기 위한 훅도 최상단에서 모달을 관리한다. 아래는 useOverlay의 root provider 코드이다.
/** @tossdocs-ignore */
import React, { createContext, PropsWithChildren, ReactNode, useCallback, useMemo, useState } from 'react';
export const OverlayContext = createContext<{
mount(id: string, element: ReactNode): void;
unmount(id: string): void;
} | null>(null);
if (process.env.NODE_ENV !== 'production') {
OverlayContext.displayName = 'OverlayContext';
}
export function OverlayProvider({ children }: PropsWithChildren) {
const [overlayById, setOverlayById] = useState<Map<string, ReactNode>>(new Map()); // <------- 여기서 모달 관리
const mount = useCallback((id: string, element: ReactNode) => {
setOverlayById(overlayById => {
const cloned = new Map(overlayById);
cloned.set(id, element);
return cloned;
});
}, []);
const unmount = useCallback((id: string) => {
setOverlayById(overlayById => {
const cloned = new Map(overlayById);
cloned.delete(id);
return cloned;
});
}, []);
const context = useMemo(() => ({ mount, unmount }), [mount, unmount]);
return (
<OverlayContext.Provider value={context}>
{children}
{[...overlayById.entries()].map(([id, element]) => (
<React.Fragment key={id}>{element}</React.Fragment>
))}
</OverlayContext.Provider>
);
}
이렇듯 전역으로 상태를 관리 하기 위해서는 최상단 루트 컴포넌트에 상태를 선언하고 이를 children으로 받아서 관리해주고 있다. 그러나 zustand는 root provider 없이 바로 컴포넌트에서 바로 사용할 수 있다. 이는 어떻게 가능한 것인가.
zustand 코드 파헤쳐 보기
핵심 로직은 생각보다 간단했다. 코드라인도 길지 않았다. 먼저 사용법은 다음과 같다.
import { create } from 'zustand'
const useStore = create((set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
}))
아래는 zustand의 타입과 불필요한 코드를 제거한 가져온 핵심 코드들이다.
import useSyncExternalStoreExports from 'use-sync-external-store/shim/with-selector'
const { useSyncExternalStoreWithSelector } = useSyncExternalStoreExports
const createStore = (createState) => {
let state
const listeners = new Set()
const setState = (partial, replace) => {
// TODO: Remove type assertion once https://github.com/microsoft/TypeScript/issues/37663 is resolved
// https://github.com/microsoft/TypeScript/issues/37663#issuecomment-759728342
const nextState =
typeof partial === 'function'
? partial(state)
: partial
if (!Object.is(nextState, state)) {
const previousState = state
state =
replace ?? (typeof nextState !== 'object' || nextState === null)
? nextState
: Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}
const getState = () => state
const getInitialState = () => initialState
const subscribe = (listener) => {
listeners.add(listener)
// Unsubscribe
return () => listeners.delete(listener)
}
const destroy = () => {
listeners.clear()
}
const api = { setState, getState, getInitialState, subscribe, destroy }
const initialState = (state = createState(setState, getState, api))
return api;
}
export function useStore(
api,
selector,
equalityFn?,
) {
const slice = useSyncExternalStoreWithSelector(
api.subscribe,
api.getState,
api.getServerState || api.getInitialState,
selector,
equalityFn,
)
return slice
}
const createImpl = (createState) => {
const api =
typeof createState === 'function' ? createStore(createState) : createState
const useBoundStore = (selector, equalityFn) =>
useStore(api, selector, equalityFn)
Object.assign(useBoundStore, api)
return useBoundStore
}
export const create = ((createState) => createState ? createImpl(createState) : createImpl);
정말이지 이게 끝이다. 물론 기타 타입코드와 여러가지가 다른 코드가 존재하지만 핵심은 이것이다. 먼저 createStore의 코드를 살펴보자.
zustand는 Observer Pattern을 사용한다. 프론트엔드에서 빠질 수 없는 패턴이랴 여기서 listener 함수를 받는데 아래 listener 함수가 어디서 왔는지 서술하겠다.
const setState = (partial, replace) => {
const nextState = 생략;
listeners.forEach((listener) => listener(state, previousState))
}
const getState = () => state
const getInitialState = () => initialState
const subscribe = (listener) => {
listeners.add(listener)
// Unsubscribe
return () => listeners.delete(listener)
}
처음 컴포넌트가 마운트 되었을 때 subscribe 함수가 호출된다. 그 이후에 상태를 업데이트 할 때 해당 파라미터가 콜백인지 아닌지 확인하고 다음 값을 갱신한 후에 리스너들을 순회하여 리스너 함수를 호출한다. 이렇게 api 객체를 만들어 useSyncExternalStoreWithSelector 의 파라미터로 넘긴다.
useSyncExternalStore
react 18이 릴리즈 되면서 나온 훅이다. 해당 게시글에서 useSyncExternalStore 를 자세히 설명할 것은 아니고 해당 훅은 외부 스토어의 tearing 현상을 막기 위해 리액트 내부와 싱크를 맞추는 훅이다.
https://www.youtube.com/watch?v=KEDUqA9JeIo 위 영상에서 useSyncExternalStore를 사용하여 커스텀 상태관리를 만들고 있다.
useSyncExternalStore 은 첫번째 인자로 subscribe 함수를 받고 있다. 두번쨰 파라미터로는 해당 상태의 스냅샷을 받는 함수를 받고있다. 여기서 두번쨰 파라미터로 상태 객체를 전달하는게 아닌 함수 형태로 () => state 전달해야 하는데 이는 클로져의 특성을 활용하여 매번 변경되는 state를 반환하기 위함이다. 여기서 중요한 것은 저 listener 함수이다. listener 함수는 대체 어디서 넣어주는 것일까?
subscribe 함수에 console.log(listener.name) 를 출력해보면 다음과 같은 함수 이름이 나타난다.
handleStoreChange
handleStoreChange
handleStoreChange 해당 함수는 여기서 주입하고 있었다. zustand는 react에서 제공하는 useState, useReducer 없이도 useSyncExternalStore 훅을 통하여 subscribe 함수 의존성을 받아서 리액트에서 상태를 관리해주고 있었디.
아래는 useSyncExternalStoreShimClient 의 일부를 가져온 것이다.
const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}});
useEffect(() => {
// Check for changes right before subscribing. Subsequent changes will be
// detected in the subscription handler.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceUpdate({inst});
}
const handleStoreChange = () => {
// TODO: Because there is no cross-renderer API for batching updates, it's
// up to the consumer of this library to wrap their subscription event
// with unstable_batchedUpdates. Should we try to detect when this isn't
// the case and print a warning in development?
// The store changed. Check if the snapshot changed since the last time we
// read from the store.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceUpdate({inst}); <----------------------------------- 상태 업데이트 코드
}
};
// Subscribe to the store and return a clean-up function.
return subscribe(handleStoreChange);
}, [subscribe]);
직접 구현해보기
이제 zustand를 참고하여 직접 커스텀 상태관리를 구현해보자. useSyncExternalStore 를 사용한다면 아주 간단한 상태관리를 구현할 수 있을 것이다. 상태 관리는 리액트에 위임하고 개발자는 그저 subscribe 함수와 상태 업데이트 시 리스너들을 호출해주면 될 것이다.
최종적으로 createStore 함수로 초기 상태값을 받고 반환받은 값을 컴포넌트의 훅형태로 쓸 수 있도록 할 것이다.
import { useCallback, useSyncExternalStore } from 'react';
const createStore = (initialState) => {
let state = initialState;
const getState = () => state;
const listeners = new Set();
const setState = (fn) => {
state = fn(state);
listeners.forEach((listener) => listener());
};
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
return { getState, setState, subscribe };
};
const useStore = (store, selector) => {
const slice = useSyncExternalStore(
store.subscribe,
useCallback(() => selector(store.getState()), [store, selector])
);
return [slice, store.setState];
};
정말이지 간단하게 만들었다. zustand 로직보다는 간단하지만 결국 핵심 패턴은 옵저버 패턴을 이용한 useSyncExternalStore 훅 사용에 있다. 여기서 useSyncExternalStore 두번쨰 인자로 상태의 스냅샷을 사용하는 쪽에서 selector로 select할 수 있도록 했다. store와 selector를 의존성으로 받아 불필요한 리렌더링이 발생하지 않도록 useCallback 으로 감싸주었다.
잘 되는지 테스트를 작성해보자.
test('형제간 상태 공유', () => {
const store = createStore({ count: 0 });
function Brother1() {
const [count, setState] = useStore(store, (state) => state.count);
return (
<React.Fragment>
<button
onClick={() => {
setState((prev) => ({ count: prev.count + 1 }));
}}
>
inc
</button>
<button
onClick={() => {
setState((prev) => ({ count: prev.count - 1 }));
}}
>
dec
</button>
<h1 data-testid="brother1">value: {count}</h1>
</React.Fragment>
);
}
function Brother2() {
const [count] = useStore(store, (state) => state.count);
return (
<React.Fragment>
<h1 data-testid="brother2">value: {count}</h1>
</React.Fragment>
);
}
function Parent() {
return (
<React.Fragment>
<Brother1 />
<Brother2 />
</React.Fragment>
);
}
const { getByText, getByTestId } = render(<Parent />);
fireEvent.click(getByText('inc'));
expect(getByTestId('brother1').textContent).toBe('value: 1');
expect(getByTestId('brother2').textContent).toBe('value: 1');
fireEvent.click(getByText('dec'));
expect(getByTestId('brother1').textContent).toBe('value: 0');
expect(getByTestId('brother2').textContent).toBe('value: 0');
});
잘 통과된다.
다음 포스트는 useSyncExternalStore 훅 이전 zustand3 버전에서는 어떻게 상태 관리를 하는지 알아보겠다.